bookstack-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """bookstack-cli: CLI tool for coding agents to interact with BookStack wiki."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,211 @@
1
+ """Async HTTP client for BookStack API with auth, retry, pagination."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from bookstack_cli.config import get_config
11
+ from bookstack_cli.exceptions import (
12
+ BookStackRateLimitError,
13
+ map_status_to_error,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BASE_DELAY = 1.0
19
+ MAX_RETRIES = 5
20
+ MAX_PAGE_SIZE = 500
21
+
22
+
23
+ class BookStackClient:
24
+ """Async HTTP client for BookStack REST API.
25
+
26
+ Handles:
27
+ - Auth header injection (Token token_id:token_secret)
28
+ - Rate-limit retry with exponential backoff
29
+ - Auto-pagination via async generator
30
+ - Error mapping to typed exceptions
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ base_url: str | None = None,
36
+ token_id: str | None = None,
37
+ token_secret: str | None = None,
38
+ timeout: float = 30.0,
39
+ ) -> None:
40
+ if base_url and token_id and token_secret:
41
+ self._base_url = base_url.rstrip("/")
42
+ self._token_id = token_id
43
+ self._token_secret = token_secret
44
+ else:
45
+ cfg = get_config()
46
+ self._base_url = cfg.url
47
+ self._token_id = cfg.token_id
48
+ self._token_secret = cfg.token_secret
49
+
50
+ auth_header_value = f"Token {self._token_id}:{self._token_secret}"
51
+
52
+ self._client = httpx.AsyncClient(
53
+ base_url=self._base_url,
54
+ headers={
55
+ "Authorization": auth_header_value,
56
+ "Accept": "application/json",
57
+ "Content-Type": "application/json",
58
+ },
59
+ timeout=httpx.Timeout(timeout),
60
+ )
61
+
62
+ async def close(self) -> None:
63
+ """Close the underlying HTTP client."""
64
+ await self._client.aclose()
65
+
66
+ async def __aenter__(self) -> "BookStackClient":
67
+ return self
68
+
69
+ async def __aexit__(self, *args: Any) -> None:
70
+ await self.close()
71
+
72
+ def __enter__(self) -> "BookStackClient":
73
+ return self
74
+
75
+ def __exit__(self, *args: Any) -> None:
76
+ import asyncio
77
+ try:
78
+ loop = asyncio.get_event_loop()
79
+ except RuntimeError:
80
+ loop = asyncio.new_event_loop()
81
+ asyncio.set_event_loop(loop)
82
+ loop.run_until_complete(self.close())
83
+
84
+ # ------------------------------------------------------------------
85
+ # Request with retry
86
+ # ------------------------------------------------------------------
87
+
88
+ async def _request(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ retry_count: int = 0,
93
+ **kwargs: Any,
94
+ ) -> httpx.Response:
95
+ """Send HTTP request with rate-limit retry."""
96
+ url = f"/api/{path.lstrip('/')}"
97
+ # Remove Content-Type for multipart (httpx sets correct boundary)
98
+ has_files = "files" in kwargs
99
+ if has_files:
100
+ old_ct = self._client.headers.pop("Content-Type", None)
101
+ try:
102
+ response = await self._client.request(method, url, **kwargs)
103
+ finally:
104
+ if has_files and old_ct is not None:
105
+ self._client.headers["Content-Type"] = old_ct
106
+
107
+ if response.status_code == 429 and retry_count < MAX_RETRIES:
108
+ retry_after = _parse_retry_after(response)
109
+ delay = max(retry_after, BASE_DELAY * (2**retry_count))
110
+ logger.warning(
111
+ "Rate limited. Retry %d/%d after %.1fs",
112
+ retry_count + 1,
113
+ MAX_RETRIES,
114
+ delay,
115
+ )
116
+ await asyncio.sleep(delay)
117
+ return await self._request(method, path, retry_count + 1, **kwargs)
118
+
119
+ if response.is_error:
120
+ _raise_for_status(response)
121
+
122
+ return response
123
+
124
+ # ------------------------------------------------------------------
125
+ # CRUD helpers
126
+ # ------------------------------------------------------------------
127
+
128
+ async def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
129
+ """GET request returning parsed JSON."""
130
+ response = await self._request("GET", path, params=params)
131
+ return response.json()
132
+
133
+ async def get_raw(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
134
+ """GET request returning raw response (e.g. for binary downloads)."""
135
+ return await self._request("GET", path, params=params)
136
+
137
+ async def post(
138
+ self, path: str, json: dict[str, Any] | None = None, data: dict[str, Any] | None = None
139
+ ) -> dict[str, Any]:
140
+ """POST request returning parsed JSON."""
141
+ response = await self._request("POST", path, json=json, data=data)
142
+ return response.json()
143
+
144
+ async def put(self, path: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
145
+ """PUT request returning parsed JSON."""
146
+ response = await self._request("PUT", path, json=json)
147
+ return response.json()
148
+
149
+ async def delete(self, path: str) -> None:
150
+ """DELETE request."""
151
+ await self._request("DELETE", path)
152
+
153
+ # ------------------------------------------------------------------
154
+ # Pagination
155
+ # ------------------------------------------------------------------
156
+
157
+ async def paginate(
158
+ self,
159
+ path: str,
160
+ params: dict[str, Any] | None = None,
161
+ page_size: int = 100,
162
+ ) -> AsyncIterator[dict[str, Any]]:
163
+ """Iterate over all pages of a list endpoint.
164
+
165
+ Yields individual items from ``data`` across all pages.
166
+ """
167
+ params = dict(params or {})
168
+ params.setdefault("count", min(page_size, MAX_PAGE_SIZE))
169
+ page = 1
170
+
171
+ while True:
172
+ params["page"] = page
173
+ data = await self.get(path, params=params)
174
+ items: list[dict[str, Any]] = data.get("data", [])
175
+ for item in items:
176
+ yield item
177
+
178
+ total: int = data.get("total", 0)
179
+ per_page = data.get("per_page")
180
+ if per_page is None:
181
+ per_page = len(items) or page_size
182
+ if page * per_page >= total:
183
+ break
184
+ page += 1
185
+
186
+
187
+ def _parse_retry_after(response: httpx.Response) -> float:
188
+ """Extract Retry-After header value as float."""
189
+ val = response.headers.get("Retry-After", "1")
190
+ try:
191
+ return float(val)
192
+ except ValueError:
193
+ return 1.0
194
+
195
+
196
+ def _raise_for_status(response: httpx.Response) -> None:
197
+ """Map HTTP status to typed BookStack exception."""
198
+ try:
199
+ body = response.json()
200
+ error = body.get("error", {})
201
+ message = str(error.get("message", response.reason_phrase))
202
+ validation = error.get("validation")
203
+ if validation:
204
+ details = "; ".join(
205
+ f"{k}: {', '.join(v)}" for k, v in validation.items()
206
+ )
207
+ message = f"{message} ({details})"
208
+ except Exception:
209
+ message = response.reason_phrase or "Unknown error"
210
+
211
+ raise map_status_to_error(response.status_code, message)
@@ -0,0 +1,131 @@
1
+ """Configuration loader for BookStack connection settings.
2
+
3
+ Config file: ~/.config/bookstack-cli/config.toml
4
+
5
+ ```toml
6
+ [connection]
7
+ url = "http://10.0.0.1:8080" # API endpoint (internal)
8
+ resolve_url = "https://wiki.example.com" # Public web URL (optional, falls back to url)
9
+ token_id = "<your-token-id>"
10
+ token_secret = "<your-token-secret>"
11
+ ```
12
+
13
+ Env vars override file values:
14
+ - BOOKSTACK_URL
15
+ - BOOKSTACK_RESOLVE_URL
16
+ - BOOKSTACK_TOKEN_ID
17
+ - BOOKSTACK_TOKEN_SECRET
18
+ """
19
+
20
+ import os
21
+ import tomllib
22
+ from pathlib import Path
23
+ from typing import NamedTuple
24
+
25
+ from bookstack_cli.exceptions import BookStackConfigError
26
+
27
+
28
+ class BookStackConfig(NamedTuple):
29
+ """Connection configuration for a BookStack instance."""
30
+
31
+ url: str
32
+ token_id: str
33
+ token_secret: str
34
+ resolve_url: str | None = None
35
+
36
+
37
+ CONFIG_DIR = Path.home() / ".config" / "bookstack-cli"
38
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
39
+
40
+
41
+ def _load_env() -> BookStackConfig | None:
42
+ """Load config from environment variables."""
43
+ url = os.environ.get("BOOKSTACK_URL")
44
+ token_id = os.environ.get("BOOKSTACK_TOKEN_ID")
45
+ token_secret = os.environ.get("BOOKSTACK_TOKEN_SECRET")
46
+ if url and token_id and token_secret:
47
+ resolve_url = os.environ.get("BOOKSTACK_RESOLVE_URL") or url
48
+ return BookStackConfig(
49
+ url=url.rstrip("/"),
50
+ token_id=token_id,
51
+ token_secret=token_secret,
52
+ resolve_url=resolve_url.rstrip("/"),
53
+ )
54
+ return None
55
+
56
+
57
+ def _load_toml() -> BookStackConfig | None:
58
+ """Load config from ~/.config/bookstack-cli/config.toml."""
59
+ if not CONFIG_FILE.exists():
60
+ return None
61
+ with open(CONFIG_FILE, "rb") as f:
62
+ data = tomllib.load(f)
63
+ conn = data.get("connection", {})
64
+ url = conn.get("url")
65
+ token_id = conn.get("token_id")
66
+ token_secret = conn.get("token_secret")
67
+ if url and token_id and token_secret:
68
+ resolve_url = conn.get("resolve_url") or url
69
+ return BookStackConfig(
70
+ url=url.rstrip("/"),
71
+ token_id=token_id,
72
+ token_secret=token_secret,
73
+ resolve_url=resolve_url.rstrip("/"),
74
+ )
75
+ return None
76
+
77
+
78
+ def get_config() -> BookStackConfig:
79
+ """Load config from env vars (priority) or TOML file.
80
+
81
+ Precedence:
82
+ 1. BOOKSTACK_URL, BOOKSTACK_RESOLVE_URL, BOOKSTACK_TOKEN_ID,
83
+ BOOKSTACK_TOKEN_SECRET env vars
84
+ 2. ~/.config/bookstack-cli/config.toml
85
+
86
+ Raises BookStackConfigError if neither source has complete config.
87
+ """
88
+ env_cfg = _load_env()
89
+ if env_cfg:
90
+ return env_cfg
91
+
92
+ toml_cfg = _load_toml()
93
+ if toml_cfg:
94
+ return toml_cfg
95
+
96
+ raise BookStackConfigError(
97
+ "BookStack config not found. "
98
+ "Run `bookstack auth` or set BOOKSTACK_URL, BOOKSTACK_TOKEN_ID, "
99
+ "BOOKSTACK_TOKEN_SECRET env vars."
100
+ )
101
+
102
+
103
+ def save_config(url: str, token_id: str, token_secret: str, resolve_url: str | None = None) -> Path:
104
+ """Save connection to ~/.config/bookstack-cli/config.toml.
105
+
106
+ Creates parent directories if needed.
107
+ Returns the path to the written file.
108
+ """
109
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
110
+ lines = [
111
+ '[connection]',
112
+ f'url = "{_escape_toml(url.rstrip("/"))}"',
113
+ ]
114
+ if resolve_url:
115
+ lines.append(f'resolve_url = "{_escape_toml(resolve_url.rstrip("/"))}"')
116
+ lines.extend([
117
+ f'token_id = "{_escape_toml(token_id)}"',
118
+ f'token_secret = "{_escape_toml(token_secret)}"',
119
+ ])
120
+ CONFIG_FILE.write_text("\n".join(lines) + "\n")
121
+ return CONFIG_FILE
122
+
123
+
124
+ def _escape_toml(value: str) -> str:
125
+ """Escape special chars for TOML basic string."""
126
+ return (
127
+ value.replace("\\", "\\\\")
128
+ .replace('"', '\\"')
129
+ .replace("\n", "\\n")
130
+ .replace("\t", "\\t")
131
+ )
@@ -0,0 +1,45 @@
1
+ """Custom exceptions for BookStack API errors."""
2
+
3
+
4
+ class BookStackError(Exception):
5
+ """Base exception for all BookStack API errors."""
6
+
7
+
8
+ class BookStackAuthError(BookStackError):
9
+ """Authentication failed (401)."""
10
+
11
+
12
+ class BookStackNotFoundError(BookStackError):
13
+ """Resource not found (404)."""
14
+
15
+
16
+ class BookStackRateLimitError(BookStackError):
17
+ """Rate limit exceeded (429)."""
18
+
19
+
20
+ class BookStackServerError(BookStackError):
21
+ """Server error (5xx)."""
22
+
23
+
24
+ class BookStackValidationError(BookStackError):
25
+ """Validation error (422)."""
26
+
27
+
28
+ class BookStackConfigError(BookStackError):
29
+ """Configuration error (missing URL or credentials)."""
30
+
31
+
32
+ STATUS_ERROR_MAP: dict[int, type[BookStackError]] = {
33
+ 401: BookStackAuthError,
34
+ 404: BookStackNotFoundError,
35
+ 422: BookStackValidationError,
36
+ 429: BookStackRateLimitError,
37
+ }
38
+
39
+
40
+ def map_status_to_error(status_code: int, message: str) -> BookStackError:
41
+ """Map HTTP status code to appropriate exception."""
42
+ exc_cls = STATUS_ERROR_MAP.get(status_code, BookStackError)
43
+ if status_code >= 500:
44
+ return BookStackServerError(message)
45
+ return exc_cls(message)